大部分的瀏覽器會以每秒 60 次的頻率刷新頁面,反過來說只要瀏覽器來不及在 16 毫秒(1000/60)內產出下一個畫面就會讓使用者感覺卡卡的,影響使用體驗,本文將會介紹如何優化瀏覽器產出畫面的效能,甚至是跳過某些階段來增加效率。(以下用 Rendering 一詞代表產出畫面的過程)
開始之前先簡單介紹一下 Rendering 的各個階段:
更詳細的解釋可以參考 Performance - How Rendering Works
製作動畫除了用 JavaScript 直接修改 DOM,還有 Animation API、CSS Animations、Transitions 等等方式,但歸根究柢都是改變了元素的 Style,而最常見的問題就是花太久時間或是在錯誤的時機修改 Style。
作為 Rendering 的第一階段,最適合修改 Style 的時機就在每一幀剛開始的時候,然而在各種因素如瀏覽器環境、其他 JavaScript 執行的影響,並不能確定瀏覽器更新畫面的頻率,且有些的動畫套件或範例會使用 setTimeout
、setInterval
來修改樣式,就容易出現太晚執行或是在一幀內修改兩次的狀況。
瀏覽器更新頁面時沒來得及 Render 出下個畫面,且即使執行
setTimeout(callback, 16)
也不一定會在 16 毫秒後立即執行。
使用 requestAnimationFrame
才能確保 JavaScript 在每一幀的開頭執行,且使用者頁面跳離分頁時會自動停止執行。
function updateScreen(time) {
// 修改 DOM 來產生動畫
}
requestAnimationFrame(updateScreen);
雖然每一幀的間隔是 16 毫秒,但扣除其他階段,最安全的執行時間是在 4 毫秒以內,如果動畫計算太過繁重例如排序、搜尋等等,可以把純計算的部分移到 Worker,算完再交由主線程修改 DOM。
另外也可以看看 WorkerDOM,在 Worker 實做了大部分的 DOM API。
過於頻繁的修改會浪費效能(16 毫秒內修改多次),最常見的例子就是 Scroll,可以把需要用到的值暫存起來,且避免在一幀的時間內註冊多次 requestAnimationFrame
:
function onScroll (event) {
// 把動畫所需的值存起來
lastScrollY = window.scrollY;
// 避免一幀內多次修改 DOM
if (scheduledAnimationFrame) return;
scheduledAnimationFrame = true;
requestAnimationFrame(updateScreen);
}
window.addEventListener('scroll', onScroll);
計算一個元素的 Computed style 首先要找出所有匹配該元素的 Selector,再利用所有 Style 算出最終的 Computed style,依據官網所述,Chrome 在計算 Computed style 時有一半的時間都花在 Selector 比對。
以下列兩個 Selector 為例,前者需要確定該元素是不是偶數順序的子元素、上層元素使否包含 .box-container
、body
有沒有 toggle
class,後者只需要確定該元素有沒有 black
這個 class,兩者在效能上有明顯差異。
body.toggled .box-container .box:nth-child(2n) {
background: #000;
}
.black {
background: #000;
}
想要切換第偶數個 .box
的背景色時,比起 :nth-child(2n)
,直接在元素加上 .black
效能會更好,尤其是元素數量非常多的情況:
// this is slower
// document.body.classList.toggle('toggle');
const container = document.querySelector('.box-container');
const boxes = container.querySelectorAll('.box');
for (let [index, box] of boxes.entries()) {
if (index % 2 === 1) {
box.classList.toggle('black');
}
}
每次改變 Styles 時瀏覽器都會檢查哪些元素需要重新 Layout,且只要動到一個元素,底下所有子元素都需要重新 Layout。
有些行為會讓瀏覽器強制 Layout,一次可能沒什麼問題,但如果是迴圈就會在一次 Rendering 中觸發多次 Layout。
以這段程式碼為例,讀取元素的 offsetWidth
時瀏覽器需要強制 Layout 才能算正確的寬度,若沒有後續操作還好,如果馬上修改 Style,下次讀取 offsetWidth
時又需要再次 Layout。
const boxes = document.querySelectorAll('.box');
for (let box of boxes) {
const width = box.offsetWidth; // 強制 Layout
box.style.width = `${width + 10}px`; // 修改 Style
}
不斷讀寫穿插的行為會引起效能爆炸,稱為 Layout Thrashing,只進行一次讀取或是把狀態儲存起來可以避免:
boxWidth += 10;
const boxes = document.querySelectorAll('.box');
for (let box of boxes) {
box.style.width = `${boxWidth}px`;
}
FastDOM 利用排序讀寫行為,把「讀寫讀寫讀寫」變為「讀讀讀寫寫寫」來減少 Layout 次數,提升效能,可以看看 Demo 中明顯的效能差異。
只要修改的 Styles 和排版有關都需要 Layout,當然修改 DOM、Resize 也是,但如果只有改變顏色相關的 Styles 就可以跳過 Layout 階段,進行 Paint 和 Compositing,詳細的觸發機制可以參考 CSS Triggers。
比觸發 Layout 更嚴重的是強制 Layout,就像是「今天以前要做完」跟「現在馬上給我」的差別,也是引起 Layout thrashing 的元凶,具體哪些行為會強制 Layout 可以看 What forces layout / reflow。
透過 DevTools 中的 Rendering、Layers 功能可以快速找到 Paint 階段的瓶頸,詳細的 Paint 階段 Debug 方式請參考 Performance - Analyze Painting & Layers。
為了盡可能的重複利用上次的繪製結果,瀏覽器會把元素獨立到不同 Layer,只重繪有改變的 Layer,除了讓瀏覽器自行判斷外,遇到效能瓶頸時可以用以下兩種 Styles 把元素獨立到不同 Layer:
.layer {
will-change: transform;
}
.more-layer {
transform: translateZ(0);
}
重繪的範圍是 Layer 中所有元素的聯集,也就是說只要螢幕的左上角和右下角各有一個點,重繪範圍就是整個螢幕。
而重繪時跟模糊有關的 Style 通常會需要更多效能,例如 box-shadow
的 blur-radius
。
除了 transform
和 opacity
之外,修改任意的 Styles 都會觸發 Paint 階段,反之只修改這兩種 Styles 就能跳過 Layout、Paint 階段。
若實在無法把動畫限制在這兩種 Style,還有另一種做法 FLIP,事先算出動畫的過程,再透過 transform
和 opacity
完成,且 FLIP 還能做到 position: fixed;
和 position: relative;
間的過渡動畫,是 transition
做不到的。
到了 Compositing 階段,能夠思考的手段就是盡可能減少 Layer 的數量,大部分情況下把元素獨立到不同 Layer 可以提升效能,但事實上這就是以空間換取時間的做法,每建立一層 Layer 都需要額外的記憶體,因此不建議在沒有測量效能的情況下就隨意把元素獨立到新的 Layer。
* {
will-change: transform;
transform: translateZ(0);
}
如果對使用者的記憶體和 GPU 有十足把握可以試試
https://developers.google.com/web/fundamentals/performance/rendering/
https://james-priest.github.io/udacity-nanodegree-mws/course-notes/browser-rendering-optimization.html